fix(studio): a stale project id in the URL no longer blocks editing for the tab - #4188
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
…or the tab The project id in the location hash is user-supplied and outlives the project it names: rename the folder, or open a bookmark from a project that is gone, and it points at nothing. useServerConnection trusted it unconditionally, so every later /api/projects/<id>/... request 404'd for the life of the tab — including the composition read that opens the SDK session. Studio then fell back to the server path for every edit, and the resolver shadow never ran either, so nothing recorded why. Telemetry, 24h after the read-reason split shipped in v0.8.51: 120 `stage: read` / `http_error` 404 reads across 5 users, about 24 each, none recovering. On this route a 404 has exactly one origin — resolveProject returned null — since the other failure modes answer 403 and the GET does not set mustExist. Validates through /api/projects/:id, which calls the same adapter.resolveProject the file routes use. Matching against the /api/projects list instead would be wrong twice over: that list omits session ids, which resolve fine, and skips project dirs without an index.html. The check is tri-state on purpose. Only a definite 404 counts as missing; a rejected request or a 5xx keeps the hash, so one network blip cannot rewrite a valid deep link out from under the user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6b1362b to
50a360c
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
APPROVE — the bug is real, the fix targets the right equivalence, and the tri-state is the correct shape. One gap worth closing and one smaller observation, both non-blocking, written out so you can decide rather than take my word for the grading.
Verified at source
- The 404 contract the tri-state rests on holds.
GET /projects/:id(packages/studio-server/src/routes/projects.ts:51-53) has exactly one 404 in it:adapter.resolveProject(...)returning null. NomustExist, no other status in the handler. So "404 ⟺ the server cannot resolve this id" is true for this route, which is what makes acting onmissingsafe. - "Matching the
/api/projectslist would be wrong twice over" — both halves check out, and this is the most interesting decision in the PR:- Session ids.
listProjectskeys entries on the project directory name (packages/studio/vite.adapter.ts:197,id: d.name) and carries the session only as a separatesessionIdfield, so a session id is never in the list's id set.resolveProjectexplicitly falls through tosessions/<id>.jsonand resolves it (:211-227). Matching against the list would have redirected a working session deep link. - Dirs without an index.
listProjectsfilters onindex.html || <name>.html(:191-192);resolveProjecthas no such filter and returns the dir if it exists (:235). Resolvable, unlisted.
- Session ids.
- Encode/decode symmetry.
parseProjectIdFromHashdecodes (projectRouting.ts:64) andresolveHashProjectre-encodes withencodeURIComponent, so an id containing a space or a%is not double-encoded on the way to the probe. - The mutation claims are true by inspection, and the tests are not vacuous. Removing the fallback kills "falls back when the hash id is gone"; collapsing
unknownintomissingkills both keep-the-hash tests. The fallback test's list project (real-project) is deliberately different from the hash id, so it proves the hash id lost rather than merely that something was set.
The gap: nothing re-validates after mount
resolveHashProject runs inside useMountEffect. The hook's own hashchange listener is untouched and still adopts any later id with no check:
// useServerConnection.ts:107-114 — unchanged by this PR
const next = parseProjectIdFromHash(window.location.hash);
if (next && next !== projectId) setProjectId(next);The reachable sequence is one click, and this PR is what creates it:
- Load
#project/deleted-project→ the probe 404s → fall back andwindow.location.hash = buildProjectHash("demo-capture")(:86). Assigning tolocation.hashpushes a history entry. - The user presses Back — plausible, because from their side the app just silently moved them off the project they asked for.
- The hash returns to
#project/deleted-project,hashchangefires,setProjectId("deleted-project")runs unvalidated. The mount effect has already run, so nothing rewrites it again: the tab is back in the exact 404-for-its-lifetime state, with the same absent telemetry.
So the title's "for the tab" holds for the load path, and stops holding one Back press later. Two ways out, not equivalent:
history.replaceState(null, "", buildProjectHash(first.id))instead of assigninglocation.hash. The dead id never enters history, so Back leaves the page rather than restoring it. Cheapest, and it does not depend on the hashchange firing sinceprojectIdis already set at:84.- Route
onHashChangethroughresolveHashProject. Covers the general case too — pasting a stale hash into a live tab — at the cost of an async listener.
I would take the first unless in-tab hash edits are a real path for you, in which case the second is the actual fix.
Why I am not blocking on it: that listener behaved identically before this PR, so nothing regresses, and the load path you targeted is genuinely fixed. What would flip it is treating the silent redirect as the user-visible recovery — Back is the normal response to being redirected.
Smaller: the hash path can now reach the retry loop
missing plus an empty project list falls through to scheduleRetry() (:88), which sets waitingForServer and polls every 2 s. A hash-carrying load could not reach that before, because the hash short-circuited above it. On a healthy server that simply has no projects, the user now sits in the waiting-for-server state indefinitely, which names the wrong cause. Narrow, and still better than the old silent breakage — worth a line only because it is a new state for this path rather than a change in degree.
Scope
I read both changed files whole, plus the three untouched pieces the fix depends on: the route's 404 origin, both adapter project resolvers, and the hash codec. I did not run the suite — the 843-pass number is from your body, and the mutation claims above I checked by reading, not by executing.
— Rames
The bug
The project id in the location hash is user-supplied and outlives the project it names. Rename the folder, or open a bookmark from a project that's gone, and it points at nothing.
useServerConnectiontrusted it unconditionally:So every later
/api/projects/<id>/...request 404s for the life of the tab — including the composition read that opens the SDK session. Studio falls back to the server path for every edit, and the resolver shadow never runs either, so nothing records why.How it was found
The
stage: readreason split shipped in v0.8.51. Within 24 hours it named the cause:http_errorabsent_or_empty~24 events per user, none recovering — the shape of a bad hash that never gets rewritten. A missing file answers
200 + ""and shows asabsent_or_empty, so these are genuinely unresolved projects, not missing compositions.On this route a 404 has exactly one origin —
adapter.resolveProjectreturned null. The other failure modes answer 403, and the GET doesn't setmustExist.The fix
Validate through
GET /api/projects/:id, which calls the sameadapter.resolveProjectthe file routes use. If it's gone, fall back to the first project and rewrite the hash — the path a hashless load already takes.Why not match against the
/api/projectslist (which the same code already fetches): that would be wrong twice over.resolveProjectresolves fine viasessions/<id>.json.index.html/<name>.html.Either would turn a working deep link into a silent redirect.
The check is tri-state on purpose. Only a definite 404 counts as
missing; a rejected request or a 5xx returnsunknownand keeps the hash. A network blip must not rewrite a valid deep link out from under the user.Before
Loading
#project/deleted-project— a hash id the server cannot resolve (GET /api/projects/deleted-project→ 404). The hash is left untouched, the file tree never loads, and the composition read 404s for the life of the tab: "No compositions found",00:00/00:00.After
Same URL, same server. The unresolvable id is detected, Studio falls back to the real project and rewrites the hash to
#project/demo-capture— tree loaded,index.htmlopen,00:00/00:03.Verification
unknownintomissingfails both keep-the-hash tests.packages/studio: 99 files, 843 tests pass.tsc --noEmitclean, oxlint 0/0, oxfmt clean.Note on the remaining
absent_or_emptyThe other 10 events are not covered here and I'm not guessing at them. The obvious cause — a stale comp path in the same URL — is already guarded:
useHydrateActiveCompPathFromUrlrunsnormalizeStudioCompositionPath(urlPath, fileTree)against the loaded tree. So that class needs its own root-cause rather than a speculative fix.🤖 Generated with Claude Code